Skip to content

feat(ape): add Agent Policy Engine extension#70

Merged
Lightheartdevs merged 7 commits intoLight-Heart-Labs:mainfrom
latentcollapse:feat/ape-policy-extension
Mar 16, 2026
Merged

feat(ape): add Agent Policy Engine extension#70
Lightheartdevs merged 7 commits intoLight-Heart-Labs:mainfrom
latentcollapse:feat/ape-policy-extension

Conversation

@latentcollapse
Copy link
Copy Markdown

Overview

APE is a lightweight policy gateway for Dream Server's autonomous agent framework. It intercepts OpenClaw tool calls before execution and evaluates them against a configurable policy.

Motivation

OpenClaw's exec tool can run arbitrary commands on the host machine. Issue #22 showed the gateway was publicly accessible — fixing the binding is necessary but not sufficient. A policy layer gives operators fine-grained control over what an agent is allowed to do, not just who can reach it.

What it provides

  • Intent classification — infers ExecuteCommand / WriteFile / ReadFile / NetworkFetch / SpawnAgent / Other from tool name and args
  • Allowlist enforcement — only approved commands execute; unsafe patterns (rm -rf, curl|sh, netcat reverse shells) blocked by regex
  • Path guardsWriteFile restricted to /workspace and /tmp/openclaw by default
  • Rate limiting — configurable RPM cap across all agent sessions
  • Audit log — every decision appended to audit.jsonl (append-only, never mutated) with tool name, intent, args keys, session ID, agent ID, and timestamp
  • Hot-reload — edit policy.yaml, APE picks it up within ~30s, no restart needed
  • Strict modeAPE_STRICT_MODE=true makes denials return HTTP 403; default is non-blocking (log and advise)

API

POST /verify    — evaluate a tool call, returns {allowed, reason, intent, decision_id}
GET  /audit     — tail the audit log (last N entries)
GET  /policy    — active policy summary
GET  /metrics   — allowed/denied/rate_limited counters
GET  /health    — liveness probe

Usage

dream enable ape

Point OpenClaw's tool middleware at http://ape:7890/verify. Runs non-blocking by default so existing workflows are unaffected until you're ready to enforce.

Footprint

  • Python 3.12-slim image
  • 256 MB RAM limit, 0.5 CPU
  • No GPU required
  • Port binds to 127.0.0.1 only

APE is a lightweight policy gateway for Dream Server's autonomous agent
framework (OpenClaw). It intercepts tool calls before execution and
evaluates them against a configurable policy, providing:

  - Intent classification: ExecuteCommand / WriteFile / ReadFile /
    NetworkFetch / SpawnAgent / Other — inferred from tool name and args
  - Allowlist enforcement: only approved commands execute; unsafe
    patterns (rm -rf, curl|sh, netcat shells) are blocked by regex
  - Path guards: WriteFile restricted to /workspace and /tmp/openclaw
  - Rate limiting: configurable RPM cap across all agent sessions
  - Audit log: every decision appended to audit.jsonl (append-only,
    never mutated) with tool name, intent, args keys, session ID,
    agent ID, and timestamp
  - Hot-reload: policy.yaml changes are picked up without restarting

API surface:
  POST /verify    — evaluate a tool call, returns allowed + reason
  GET  /audit     — tail the audit log (last N entries)
  GET  /policy    — active policy summary (no sensitive values)
  GET  /metrics   — allowed/denied/rate_limited counters
  GET  /health    — liveness probe

Enable with:
  dream enable ape

OpenClaw integration: point OpenClaw's tool middleware at
http://ape:7890/verify. APE runs non-blocking by default (STRICT_MODE
can be set to enforce hard blocks). The port binds to 127.0.0.1 only.

Footprint: ~256 MB RAM limit, 0.5 CPU, Python 3.12-slim image,
no GPU required.
The full APE engine with formal Rocq/Coq conscience proofs (G1-G6)
and trust algebra is open-source at:
  https://github.com/latentcollapse/HLX_research_language (AGPL v3)

This extension is a lightweight Python reimplementation of the core
policy verification concept, designed to fit Dream Server's extension
architecture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@Lightheartdevs Lightheartdevs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good work — clean compose config, pinned deps, localhost binding, no-new-privileges, resource limits, hot-reload. This is solid for a v1 optional extension.

One required change before merge:

path_guard in main.py uses raw str.startswith() without path normalization:

if any(path.startswith(p) for p in allowed_paths):

A path like /home/node/.openclaw/workspace/../../../etc/passwd passes this check because it starts with the allowed prefix before traversal. Fix:

import posixpath
# ... in the path_guard block:
normalized = posixpath.normpath(path)
if any(normalized.startswith(p) for p in allowed_paths):

This is a one-line fix. Since APE is specifically a security extension, it should ship without a path traversal bypass.

Everything else (in-memory rate limiting, hardcoded CORS, no auth on /verify) is acceptable for v1. Please push the normpath fix and we'll merge.

str.startswith() on a raw path allows traversal bypasses:
  /home/node/.openclaw/workspace/../../../etc/passwd
passes the check even though it resolves outside the allowed zone.

Use posixpath.normpath() to resolve .. segments before comparing
against allowed_paths. Since APE is a security extension it should
ship without this bypass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@Lightheartdevs Lightheartdevs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Security vulnerabilities in the policy engine

Well-architected extension — the intent classification → policy evaluation → audit trail pipeline is clean, the Docker compose hardening is excellent (loopback binding, no-new-privileges, resource limits, pinned deps), and the hot-reload via mtime check is efficient. The CORS restriction and audit JSONL format are good choices.

However, for a security-focused service, there are two exploitable bypasses that need fixing:

🔴 Path traversal via symlinks + prefix matching

The path_guard mode validates paths like this:

normalized = posixpath.normpath(path)
if any(normalized.startswith(p) for p in allowed_paths):
    return True

Two bypass vectors:

  1. Symlink escape: If /tmp/openclaw/evil is a symlink to /etc/shadow, normpath resolves .. but does not resolve symlinks. The path passes the check (starts with /tmp/openclaw) but the write follows the symlink to /etc/shadow.

  2. Prefix collision: With allowed_paths: ["/tmp"], the path /tmpevil/payload passes because "/tmpevil/payload".startswith("/tmp") is True.

Fix:

normalized = os.path.realpath(path)  # resolves symlinks
if any(normalized == p or normalized.startswith(p + "/") for p in allowed_paths):
    return True

🔴 Empty command bypasses the allowlist

if not command:
    return True, "no command specified"

When the policy mode is allowlist, an empty or missing command field results in allow. An attacker can craft a tool call with the command in a different key (e.g., "cmd" vs "command", or embedded in another field) that the downstream tool executor processes but APE doesn't see.

Fix: Under allowlist mode, an empty command should deny, not allow:

if not command:
    return False, "no command specified (required for allowlist policy)"

⚠️ Default policy allows python3 and node

The shipped policy.yaml allowlists python3 and node, which enable arbitrary code execution — the exact threat APE is designed to prevent. An agent can python3 -c "import os; os.system('rm -rf /')" and it passes the allowlist check (base command is python3, which is allowed).

Fix: Remove python3 and node from the default allowlist. If users need them, they can add them explicitly and accept the risk.

⚠️ Intent classification false positives

Substring matching causes misclassifications:

  • "brush" contains "sh" → misclassified as ExecuteCommand
  • "dashboard_read" contains "read" → misclassified as ReadFile

Consider using word boundary matching or an exact-match set for tool name classification.

Minor issues

  1. Rate limiter is global, not per-session: One busy agent can exhaust the limit for all agents. Consider per-session_id buckets.

  2. 0.0.0.0 in main.py: uvicorn.run(app, host="0.0.0.0") binds to all interfaces. Docker's port mapping (127.0.0.1:7890:7890) prevents external access, but the Python code should also bind to 127.0.0.1 for defense in depth.

  3. Manifest gpu_backends: Only lists [amd, nvidia]. After PR #120 lands this won't matter for apple (all Docker services load), but it's inconsistent with the broader push to add apple support.


The architecture is sound and the audit/metrics design is production-quality. The path traversal and empty command bypasses are the priority fixes — the rest are hardening suggestions.

@latentcollapse
Copy link
Copy Markdown
Author

latentcollapse commented Mar 10, 2026 via email

- Remove python3/node from default command allowlist (arbitrary code exec)
- Deny empty commands instead of allowing them (allowlist bypass)
- Use word-boundary token matching for intent classification (false positives
  on tool names like "bash_tools_wrapper" or "get_shell_result")
- Switch to per-session rate limiting (global deque let one agent exhaust
  quota for all others)
- Replace posixpath.normpath with os.path.realpath for path_guard (normpath
  does not resolve symlinks; also fix prefix collision: /tmp matching /tmp-evil)
- Bind uvicorn to 127.0.0.1 instead of 0.0.0.0
- Add apple to gpu_backends in manifest.yaml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@Lightheartdevs Lightheartdevs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request Changes

The architecture is well-designed and directly addresses H3 from PR #71. Three issues to fix before merge.

🔴 Dockerfile runs as root

A security enforcement service should not run as root in its own container. The Dockerfile has no USER directive. One-line fix:

RUN adduser --system --no-create-home ape
USER ape

This is consistent with the project's existing pattern — dashboard-api runs as dreamer:1000, ComfyUI as comfyui:1000.

🔴 No authentication on APE's own endpoints

POST /verify and GET /audit are unauthenticated. Since APE runs on the Docker network, any container can reach it via ape:7890 — including a compromised agent, n8n workflow, or any other service on dream-network. An attacker could:

  • Probe policy details via /verify to map the allowlist
  • Read the full audit log via /audit

Add a shared-secret header check (validated against an env var like APE_API_KEY), consistent with how dashboard-api handles auth.

🟡 Missing OpenClaw integration documentation

The PR description says "Point OpenClaw's tool middleware at http://ape:7890/verify" but provides no config change, compose override, or step-by-step to wire this up. An operator who runs dream enable ape gets a running but disconnected service. Either:

  • Add a compose override that configures OpenClaw's middleware URL, or
  • Add a setup section in the README/manifest with explicit steps

🟡 Default advisory mode needs a prominent warning

APE_STRICT_MODE=false (the default) means APE is purely advisory — it logs decisions but never blocks anything. An operator who deploys APE assuming it enforces policy has a false sense of security. Add a startup log line like:

WARNING: APE is running in advisory mode. Tool calls are logged but NOT blocked. Set APE_STRICT_MODE=true to enforce policies.

🟡 decision_id entropy is too low

decision_id = f"{int(time.time() * 1000)}-{id(req) & 0xFFFF:04x}"

id(req) cycles through a small range (CPython reuses memory addresses for request objects). The 4-digit hex suffix provides only 65,536 possible values — collisions within the same millisecond are plausible in high-volume logs. Use secrets.token_hex(8) or uuid.uuid4().

🟡 /audit endpoint loads entire log into memory

lines = AUDIT_LOG.read_text().strip().splitlines()
entries = [json.loads(l) for l in lines[-last_n:] if l.strip()]

For a long-running system with a large audit log, this loads the entire file before slicing. Use a tail-read approach (seek from end) or a collections.deque(maxlen=last_n) reader.

What's good

  • YAML policy hot-reload via mtime check — no restart needed to update rules
  • Intent classification with verb-set matching and args-based fallback is a reasonable heuristic
  • Path guards use os.path.realpath() for symlink resolution — correct approach to prevent traversal
  • Append-only audit log with structured JSON entries is the right pattern for forensics
  • Rate limiting per session with configurable window
  • Pinned dependencies in requirements.txt — good for reproducibility
  • 127.0.0.1 binding and no-new-privileges:true — correct security posture
  • manifest.yaml correctly marks the service as optional with explicit dream enable ape required

Matt added 3 commits March 10, 2026 23:58
- APE: Run as non-root user (ape) in container
- APE: Add API key authentication on /verify and /audit endpoints
- APE: Auto-generate API key at startup if not provided
- APE: Add advisory mode warning at startup
- APE: Fix decision_id entropy using secrets.token_hex(8)
- APE: Use tail-read for /audit endpoint to avoid loading entire file
- HVAC: Redact hardcoded LiveKit credentials, use env vars instead
Resolved conflict in hvac-token-server.py
These enable arbitrary code execution - users can add them explicitly if needed
Copy link
Copy Markdown
Collaborator

@Lightheartdevs Lightheartdevs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean security extension that addresses Issue #22 (unrestricted agent exec).

Well-architected: FastAPI proxy with YAML policy config, rate limiting, non-root Dockerfile, compose integration. The policy allowlist for exec, path guards for writes, and deny patterns for dangerous commands are all sensible defaults. 7 files, manageable scope.

@Lightheartdevs Lightheartdevs merged commit 2313af3 into Light-Heart-Labs:main Mar 16, 2026
3 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants